Skip to content

fix: Report each composition branch error once, with a structured error - #53

Open
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/composition-branch-errors
Open

fix: Report each composition branch error once, with a structured error#53
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/composition-branch-errors

Conversation

@shadowhand

Copy link
Copy Markdown

Closes #52.

Composition branch errors were lost, duplicated, or inconsistent between
keywords, and getErrors() could come back empty. Three defects, all in the
branch-error plumbing shared by allOf / anyOf / oneOf:

  1. Empty error list. AbstractCompositionalValidator::validateBranch()
    caught the InvalidDataTypeException thrown by SchemaValueNormalizer and
    wrapped it in a bare ValidationException — no errors:, no
    abstractErrors. Nothing structured was ever synthesised for the rejected
    value, so getErrors() was [] and getFormattedErrors() was "".
    OneOfValidatorWithContext::validateWithoutDiscriminator() reached the same
    end state via catch (InvalidDataTypeException) { continue; }.
  2. allOf duplication. validateBranch() returned each failure twice — as
    errors: [$e] and again as abstractErrors extracted from
    $e->getErrors() — and AllOfValidator merged both buckets.
    AnyOf/OneOfValidator read only abstractErrors, hence the disagreement
    between keywords.
  3. Miscounted failures. The allOf message formatted
    count($result->errors), which a branch throwing a bare
    AbstractValidationError never reached — "but 0 failed" beside a
    non-empty error list.

Changes

  • BranchOutcome and ValidationResult now carry a single canonical error
    list
    (list<ValidationErrorInterface>) instead of two overlapping ones, so
    no caller can double-count. ValidationResult::$abstractErrors is merged
    into $errors, and a new $failedCount records how many branches did not
    match.
  • Where InvalidDataTypeException was swallowed, both validator families now
    synthesise a TypeMismatchError carrying the branch's dataPath and
    schemaPath (/allOf/0, /oneOf/1, …) with actual: 'null' for the common
    case — following the DependentSchemasValidator precedent of never throwing
    with an empty list. A ValidationException from a branch that carries no
    structured errors falls back to NestedValidationError for the same reason.
  • The allOf message derives its count from failedCount, not from an error
    bucket.
  • MAX_COMPOSITION_ERRORS / TooManyErrorsError now caps the merged list.
    Behaviour is equivalent for the previously-counted case (20 branch errors →
    20 + TooManyErrorsError); previously allOf re-appended the wrapper
    errors after the cap, so a 30-failing-branch allOf returned 41 errors
    despite the cap. It now returns 21. Covered by
    CompositionBranchErrorsTest::composition_errors_are_capped.

Error identity is preserved: nested branch failures keep their original
keyword / dataPath / schemaPath rather than being flattened into a generic
wrapper. DiscriminatorDataError and OneOfError reporting are untouched.

Before / after

validateSchema(), running the repro from #52 verbatim
(B = FormIdentifier, errors= is count($e->getErrors())):

case before after
{allOf: [B]} + null errors=0, "but 1 failed", (no paths) errors=1, "but 1 failed", type@/
{anyOf: [B]} + null errors=0, (no paths) errors=1, type@/
{oneOf: [B]} + null errors=0, (no paths) errors=1, type@/
{allOf: [B]} + "not-an-object" errors=1, "but 0 failed", type@/ errors=1, "but 1 failed", type@/
B alone + {type: widgets} errors=1, required@/ errors=1, required@/ (unchanged)
{allOf: [B]} + {type: widgets} errors=2, enum@/type, enum@/type errors=1, enum@/type
{anyOf: [B]} + {type: widgets} errors=1, enum@/type errors=1, enum@/type
{oneOf: [B]} + {type: widgets} errors=1, required@/ errors=1, required@/

Message wording for anyOf / oneOf is unchanged.

PSR-7 validateResponse(), root-level content.application/json.schema of
{allOf: [$ref B]}:

body before after
null errors=0, getFormattedErrors() = "" errors=1, Expected type "object", but got "null" at /
{"type":"widgets"} errors=2, the same enum error printed twice errors=1, printed once

And {allOf: [B, C]} with both branches failing now reports one entry per
distinct violation with "but 2 failed" (was 4 entries).

Tests

Written before the fix, each verified failing against main first:

  • tests/Unit/Validator/SchemaValidator/CompositionBranchErrorsTest.php
    AllOf/AnyOf/OneOfValidator (the non-context family): per-branch
    schemaPath indices, single-branch and multi-branch failure counts,
    multiple errors from one branch, keyword agreement, and the error cap.
  • tests/Integration/Validator/Schema/OneOfValidatorWithContextTest.php
    OneOfValidatorWithContext: structured errors for rejected null
    (including untyped, scalar, and type array branches), no duplication,
    accumulation across branches, and preservation of every error from a single
    branch.
  • tests/Functional/Schema/CompositionBranchErrorsTest.php — the Composition validators lose, duplicate, and disagree about branch errors; getErrors() can be empty #52 repro
    through validateSchema(), plus getFormattedErrors() is never empty and
    never repeats.
  • tests/Functional/Response/CompositionBranchErrorsTest.php — the PSR-7
    validateResponse() path with a null body.

ValidationResultTest is updated for the merged bucket.

make tests (7161 tests, green — the 2 reported deprecations are pre-existing
and unrelated), make psalm (no errors), make cs-fix, make rector all
clean. make infection scoped to the seven touched files: covered MSI 85% →
91% (gate is 78%). The remaining escaped mutants are on lines this PR does
not modify, plus one harmless array_values() unwrap.

Notes

  • Out of scope, per the issue's "Related observation": which of several
    violations surfaces first still depends on the wrapper — branches go through
    the non-context SchemaValidator, which orders keyword groups differently
    from SchemaValidatorWithContext. This PR does not change that (see the
    oneOf + {type: widgets} row: required@/ before and after), and neither
    path is exhaustive. The one functional assertion that would have straddled
    the two families is scoped to allOf/anyOf with a comment pointing at Composition validators lose, duplicate, and disagree about branch errors; getErrors() can be empty #52.
  • ValidationResult is a shared internal DTO of the compositional validators
    (its only callers are the four classes touched here); its property set
    changes as described above. ValidationException::getErrors() and the
    AbstractValidationError shape are unchanged — this PR only adds errors
    where there were none.
  • Independent of fix: Honor nullable when it sits beside a composition keyword #51 (issue Combining "nullable" and "allOf" does not work correctly #50), which touches the same three validate()
    methods but only adds an early return at the top of each. This branch is
    cut from main; it will need no more than a trivial rebase whichever lands
    first.

allOf/anyOf/oneOf could throw a ValidationException with an empty
getErrors(): a value rejected while normalizing a branch (typically
null against a non-nullable branch) was wrapped in a bare exception
carrying nothing structured, so getFormattedErrors() returned "".
allOf also reported every branch error twice, and counted failures
from a bucket that bare AbstractValidationError branches never
reached — hence "but 0 failed" beside a non-empty error list.

BranchOutcome and ValidationResult now carry a single canonical
error list plus a failedCount; a rejected value synthesises a
TypeMismatchError with the branch dataPath/schemaPath; and the
allOf message counts the branches that did not match. The
MAX_COMPOSITION_ERRORS cap counts the merged list.

Closes duyler#52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Composition validators lose, duplicate, and disagree about branch errors; getErrors() can be empty

1 participant